iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
AI Engineering

同一把尺,30 天橫評 AI Agent Skill:從單篇實測到一份能查的選用指南系列 第 9 篇

Day 9 | 我設計了一個只有進階工具才追得到的漏洞,結果人工也追到了

  • 分享至 

  • xImage
  •  

寫這篇之前,我對今天的結果其實有一個很篤定的預設劇本:CodeQL 追到、人工追不到,證明進階工具有它不可取代的價值。實測完全不是這樣,這篇能寫成現在這個樣子,是因為先把那個劇本放下,老實面對測出來的東西。Day 6 測完 Semgrep,留了一個問題沒解:Semgrep OSS 版沒有跨檔案分析能力,只能看單一檔案。今天想接著問——換一個真正做跨檔案資料流追蹤的工具,會不會補上這塊?測的是同一個 Trail of Bits 套件裡的另一支:CodeQL。結果不是我原本預期的那樣,這個意外比我原本想證明的事更值得記。

技能卡|CodeQL

  • 名稱:codeql,Trail of Bits static-analysis套件的子技能,底層是 GitHub 官方的 CodeQL 命令列工具
  • 跟 Semgrep 的根本差異:Semgrep 比對的是語法結構(這段程式碼長得像不像一個已知的危險模式);CodeQL 先把整個程式庫「編譯」成一個可查詢的資料庫,再用真正的資料流分析(dataflow/taint tracking)去問「這個值從進來到用掉,中間有沒有經過任何一個檔案、任何一層函式呼叫」
  • 支援語言:Python、JavaScript/TypeScript、Go、Java/Kotlin、C/C++、C#、Ruby、Swift
  • 前置需求:CodeQL CLI(brew install --cask codeql)+ jq + uv,另外要另外下載對應語言的查詢包(例如 codeql/python-queries)
  • 流程:建資料庫 → 產生資料擴充模型(讓它認得框架自己包的呼叫,例如 Django/Flask 的請求解析)→ 選查詢集 → 跑分析 → 產出 SARIF
  • 這次證據狀態:兩個合成案例(跨三個檔案的路徑穿越漏洞,一個有洞、一個已修),CodeQL 各跑一次;另外一組不用任何工具的盲測基線

CodeQL 怎麼做到「跨檔案」這件事

值得花一點篇幅講清楚機制,不然接下來的結果看不出為什麼特別。CodeQL 的做法分兩階段:第一階段先把整個程式庫「萃取」成一個資料庫——不是真的執行程式,是把程式的語法樹、呼叫關係、變數的賦值與傳遞路徑,全部轉成可以查詢的結構化資料,存進一個資料庫檔案裡。這一步跟語言有沒有需要編譯有關:Python、JavaScript、Ruby 這類直譯語言不用真的跑起來,CodeQL 直接讀原始碼就能萃取;C、Java、Go 這類需要編譯的語言,CodeQL 得在旁邊真的攔截一次編譯過程,把編譯器實際看到的型別資訊也記下來,資料庫才會完整。

第二階段才是真正的分析:拿一條寫好的「查詢」(用 CodeQL 自己的查詢語言 QL 寫成),在這個資料庫裡問「有沒有一條路徑,從某個『來源』(source,例如網址參數、表單欄位)一路不經過任何『清洗點』(sanitizer),走到某個『終點』(sink,例如執行 SQL、開檔案、跑指令)」。這條路徑可以合法地跨越函式呼叫、跨越檔案邊界,因為資料庫裡本來就記錄了完整的呼叫關係,不需要像 Semgrep 那樣把每個檔案當成獨立單位分開看。這也是為什麼今天要測的東西,換成 Semgrep 完全無解——它從架構上就沒有「先建一個全域資料庫」這一步,天生只能看單一檔案的內容。

上手前要知道的事:這不是一個裝完就能跑的工具

跟 Semgrep 比起來,CodeQL 的門檻明顯高一截,這點事先講清楚比較公平。裝好 CLI 本體之後,還得另外下載對應語言的查詢包(例如 Python 就要另外抓 codeql/python-queries,這個套件本身也要花時間下載),而且它的官方文件明講:不要直接把查詢包的名字丟給分析指令,因為每個查詢包都內建了一份「預設要跑哪些查詢」的過濾清單,直接用預設值很容易在你完全不知情的狀況下漏掉一整批查詢、卻拿到一個看似正常的空結果。正式的做法是自己產生一份明確的查詢清單,再拿這份清單去跑分析,確保不會被套件內建的隱藏過濾邏輯把結果篩空。

還有一個 macOS 特有的坑:Apple Silicon 上編譯需要真的建置的語言(C、C++、Java 這類),系統工具跟 CodeQL 自己的追蹤函式庫如果架構對不上,程序會被系統直接強制關閉,錯誤代碼是 137——第一次看到這個代碼很容易誤判成「建置失敗」,其實是架構不匹配,需要額外的工具鏈調整才能解決,不是程式碼本身有問題。今天測的是 Python,不需要真的建置,沒有踩到這個問題,但如果之後要測需要編譯的語言,這個坑要事先知道。

這些門檻不是在勸退,是想講清楚一件事:CodeQL 的定位本來就不是「跟 Semgrep 一樣快速、隨手一跑」的工具,它的設計假設你願意花時間把查詢集、資料擴充模型這些東西弄對,換來的是分析的深度跟精確度。如果你要的是隨手跑一次、看看有沒有明顯問題,Semgrep 的門檻低很多;如果你要的是徹底追蹤一個大型專案裡的資料流,CodeQL 的門檻是換取深度必須付出的代價。

這次案例故意設計成「一般工具追不到」

Day 6 測 SQL injection 的時候,Semgrep 靠的其實是「字串拼接直接餵進 execute()」這種單一檔案內就看得完的語法模式,跟跨不跨檔案沒關係。這次想真的測到「跨檔案」這個能力本身,所以案例換了一種漏洞:路徑穿越(path traversal)。

三個檔案疊起來:routes.py 的 Flask 路由從網址參數拿到 filename,直接傳給 file_service.py 的 read_report();這支函式把字串接成 "reports/" + name,再傳給 storage.py 的 load_file();這支函式做的事只有一行——open(path)。單獨看 storage.py 這一行,完全看不出問題,open() 是最普通不過的動作;只有追回前面兩層、知道這個 path 最終源頭是一個沒經過任何清洗的網址參數,才知道它危險。乾淨版的差別只在 storage.py 多了一段檢查:把路徑解析成絕對路徑,確認沒有逃出指定的資料夾。

先用 Semgrep 對兩個版本各跑一次確認基準:兩邊都是 0 個結果。 這代表這次的案例設計成功排除了「靠單一檔案內語法模式就抓得到」的可能,接下來測的才是真正的跨檔案能力。

這個基準確認的步驟本身值得記一筆方法論:測一個工具「特有」的能力之前,得先確認案例沒有留一條後門讓別的、比較簡單的方法也能矇對答案。如果沒有先跑這一輪 Semgrep 確認,今天就算 CodeQL 抓到了,也沒辦法區分「這是靠跨檔案分析抓到的」還是「這個漏洞本來隨便什麼工具都抓得到,只是剛好也被 CodeQL 抓到」。先把簡單方法能矇對的可能性排除掉,才能放心把接下來的結果歸功給真正要測的那個能力。

CodeQL 的結果:抓得又準又完整

建資料庫、跑 codeql/python-queries 這包官方查詢集,有洞的那個版本跑出 1 個結果,規則是 py/path-injection,位置精準指在 storage.py 的 open() 那一行,訊息寫著「這個路徑的值依賴一個使用者提供的值」。

更值得看的是它附的完整資料流路徑,一路展開,逐檔逐行:

routes.py:9   request.args.get() 取出 filename
routes.py:10  filename 傳進 read_report()
file_service.py:4-6   name 接成 path,再傳進 load_file()
storage.py:1-2        path 傳進 open()

三個檔案、四個跳點,全部自動串起來,不用我自己手動去對照呼叫關係。這種帶完整路徑的輸出,在 SARIF 格式裡叫做「code flow」,是 CodeQL 特有、Semgrep OSS 版沒有的東西——Semgrep 的發現只會告訴你「這一行有問題」,CodeQL 的發現會告訴你「這一行有問題,而且問題是從那邊那一行流過來的,中間經過這幾站」。對一個要動手修的人來說,這個差異決定了你是要自己回頭去猜資料從哪裡來,還是直接拿到一份現成的路徑圖。

更實用的是,這份路徑本身就可以直接拿來寫成回歸測試——每一站的檔案跟行號都給好了,等於是一份現成的「這條攻擊路徑長怎樣」的說明書,不用自己重新追一遍。乾淨版重跑一次,0 個結果——它正確辨認出那段「解析絕對路徑、確認沒有逃出資料夾」的檢查是有效的防護,沒有誤判。這輪測起來,CodeQL 完全做到了它宣稱的事:語法上看不出問題的 sink,只要有真正的跨檔案汙染路徑,還是抓得到;而且沒有對已經修好的版本亂噴假警報。

意外:不用任何工具,也追到了同一條路徑,而且抓得更多

照原本的設計,接下來應該要派一個不用任何工具的盲測去讀同樣三個檔案,證明它追不到跨檔案的關聯,襯托出 CodeQL 的價值。結果完全不是這樣。

盲測基線不但完整重建了一模一樣的資料流——「request.args.get("filename") 沒驗證 → 字串接成路徑,沒清洗 .. → 直接 open()」,連攻擊 payload 都直接寫出來:GET /download?filename=../../../../etc/passwd。而且它抓到的問題比 CodeQL 那一條規則還多:這個路由完全沒有身份驗證,任何人都能觸發;filename 沒帶參數時 "reports/" + None 會直接丟例外;open() 沒包 try/except,檔案不存在或沒權限時的例外差異可以拿來當「探測資料夾裡有什麼檔案」的工具;讀到的內容原封不動當 HTML 吐回去,如果檔案內容剛好可控,這條路徑遍歷漏洞還能跟其他弱點串成反射式 XSS。

CodeQL 那條規則抓到的,是這五、六個問題裡最核心的那一個;盲測基線抓到的是同一個核心問題,外加四五個 py/path-injection 這條規則的設計範圍本來就不涵蓋的東西——因為它是一條專門找路徑穿越的規則,不是一個全面的程式碼審查。值得比較一下兩邊的產出形式:CodeQL 給的是一條有精確座標、可以重複驗證、附完整證據鏈的單一發現;盲測給的是一份讀起來更全面、但沒有座標、換一個人重寫一次措辭可能會不一樣的敘述。兩者不是互相取代的關係——真的要交出一份能重複執行、能接進 CI 的檢查,還是得靠 CodeQL 這種能精確重現的機制;要的是一次性、盡量看廣一點的審查,找人讀一遍可能更划算。

這個意外告訴我什麼

我原本設計這個案例,是想證明「跨檔案追蹤是進階工具才有、人工做不到的事」。今天測出來的答案更誠實:在一個只有三個檔案、呼叫鏈只有三層的小專案裡,追蹤跨檔案的資料流,並沒有超出仔細讀一遍程式碼的範圍。 這不是說 CodeQL 沒有用,是說今天這個案例的規模,剛好落在「小到人工還追得完」的區間——這個區間本來就不是跨檔案分析工具真正要解決的問題。

回頭想這件事,其實也不算太意外。人在讀三個檔案、追一條只有四個跳點的呼叫鏈時,本來就沒有超出正常工作記憶能處理的範圍——這跟 Day 8 測 i-have-adhd 那篇提到的「工作記憶容量小」剛好是同一件事的兩面:資訊量在可負荷範圍內,人工處理起來又快又準,甚至比一條寫死的規則更靈活,因為人會順手注意到規則沒設計要看的東西(像是今天的身份驗證缺失);資訊量一旦超出負荷,才是自動化工具真正該上場、也才真正贏得過人工的地方。CodeQL 存在的意義,不是「比人聰明」,是「不會因為呼叫鏈太長就記不住」,這跟聰不聰明是兩回事。

CodeQL 真正的價值,應該出現在案例規模超過人工能一次讀完的時候——幾十個檔案、呼叫鏈繞了七八層、中間夾雜著框架自己包裝過的請求處理邏輯,這種規模下沒有人有辦法在腦中維持完整的呼叫圖,這時候「自動建一個可查詢的資料庫、機器幫你把呼叫鏈全部串起來」才是省下人工做不到的事,不是省下人工「懶得做」的事。今天的三檔案案例沒有測到這一層,是這次測試設計上的侷限,不是 CodeQL 沒有這個能力——只是這個能力今天沒有被逼出來,因為題目出得不夠大。

這對你有什麼用

  • 先問自己的程式庫有多大,再決定要不要上這種重量級工具。 如果你負責的是一個檔案數不多、呼叫關係一眼能看完的小型服務,先花時間仔細讀一遍或找人 review,可能比架設 CodeQL 整套建置流程更划算;規模一旦大到沒有人能通讀,才是這類工具真正該上場的時候。
  • 規則抓到的範圍,永遠只有它設計要抓的那一種問題。 py/path-injection 準確抓到路徑穿越,但完全不會提醒你這個路由沒有身份驗證——這不是它的缺陷,是它的設計邊界,同一批程式碼永遠需要不只一種角度去看。
  • 不要因為工具聽起來更進階,就假設它一定贏過人工。 今天的結果沒有一邊完全勝出,是各自抓到不同範圍的東西——CodeQL 精準、可重複、附完整證據鏈;人工這次抓得更廣,但沒有任何自動化保證,換一個人重看一次,抓到的東西不見得一樣。
  • 設計測試案例時,先想清楚「這個案例想逼出工具的哪個能力」,再檢查案例規模是不是真的搆得到那個能力的門檻。 今天算是自己踩了一次這個坑——案例規模沒抓對,原本想測的能力沒被真正考驗到。
  • CodeQL 附的完整資料流路徑,即使不打算長期使用這個工具,拿來當一次性的「追蹤這個漏洞怎麼發生的」也划算。 尤其是懷疑某個問題橫跨好幾個模組、自己手動追覺得麻煩的時候,先建一次資料庫、跑一次分析,比自己一層一層點進去追可能還快。
  • 上手前先掂量團隊願不願意花時間弄對查詢集跟資料擴充模型。 如果沒有人力維護這套設定,隨便跑預設值反而可能因為套件內建的隱藏過濾邏輯而漏掉一整批查詢,得到一個看起來乾淨、其實只是沒認真跑的結果,比完全不用還危險。

誠實交代這次測試的限制

  • 案例只有三個檔案、四個跳點,遠遠稱不上「跨檔案分析工具才處理得了的規模」,今天的結果不能推論到大型專案的情境。
  • 盲測基線只跑了一次,樣本數是 1;不同的人、不同的模型、甚至同一個模型換一次跑,抓到的範圍可能不會一模一樣,今天測到的「人工抓得比 CodeQL 這條規則廣」不是穩定的通用結論。
  • 只測了 CodeQL 官方查詢集裡的路徑穿越這一條規則,沒有測到 Trail of Bits 自己的擴充規則、也沒有測到其他語言(Go、Java 這類需要真的編譯的語言,建資料庫的流程更複雜,今天完全沒碰)。
  • CodeQL 的資料擴充模型(data extensions,讓它認得框架自己包的請求處理邏輯)這次沒有特別設定,直接用預設的辨識能力;如果專案用了比較特殊的框架包裝方式,結果可能不會這麼乾淨。
  • 沒有測到案例規模真的大到人工讀不完的情境,這正是今天最大的落差,也是接下來如果要更嚴謹驗證 CodeQL 價值時該補的部分。
  • 盲測基線這次剛好抓得很完整,不代表每次找人(或每次換一個模型)重跑都會有一樣的表現——人工審查的品質本來就有變異,今天只是抓到其中一次表現不錯的結果,不能當成「人工永遠比較廣」的保證。
  • CodeQL 這次的執行環境(資料庫建置、查詢分析)都是在乾淨的合成專案上跑,沒有測到真實世界程式庫常見的雜訊——大量無關程式碼、框架版本不一致、部分模組建置失敗這類情況,都可能讓資料庫的萃取品質打折扣,這點今天完全沒測到。

跟前面幾天放在一起看

前八天陸續測出「工具有它看得到跟看不到的範圍」,今天多一層:工具的能力邊界,跟案例的規模互相牽動。 同一個工具在小案例上可能看不出優勢,換到大案例可能就是唯一可行的方法——這代表評估一個工具值不值得用,不能只問「它能不能做到某件事」,還要問「我的情境有沒有大到真的需要它做到那件事」。今天算是自己出的題目沒出對,但這個「出錯題目」本身也是一種收穫——至少現在知道下次要測這類工具的真正優勢,案例規模得往上加,不能停在三個檔案。

今天也順帶留一個沒解決的問題給自己:多大的專案才算「人工追不動」?三個檔案顯然不算,那十個檔案呢、三十個呢?這條界線在哪裡,今天沒有答案,只知道它存在,而且比三個檔案高很多。如果之後有機會拿一個真實世界、規模夠大的專案重跑一次同樣的測試,這條界線在哪裡才會真的被量出來,不會只是一句「應該在更大的規模才有優勢」的空話。

]這系列接下來還是每篇一個獨立的技能卡,累積起來變成一份你自己就能查的 skill 選用清單。


上一篇
Day 8 | 不是變短,是變得能馬上做
下一篇
Day 10 | 少了六個問題,換來一組親手驗證的數字
系列文
同一把尺,30 天橫評 AI Agent Skill:從單篇實測到一份能查的選用指南 共 11 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言